iT邦幫忙

2026 iThome 鐵人賽

0
Software Development

Kotlin Lambda 從零開始系列 第 34

Kotlin Lambda 從零開始 Day 34:泛型進階 — 型變、星號投影與 reified

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260807/201219480fj7uFk1cv.jpg

這篇文章會講清楚 out(協變)、in(逆變)、*(星號投影) 和 reified 這四個泛型進階概念,用 TDD 手刻 myFilterIsInstance 來體驗 reified 的威力

Kotlin ↔ C# 對照表

Kotlin C# 備註
out T(協變) out T 語法一樣,概念一樣
in T(逆變) in T 語法一樣,概念一樣
List<*> 沒有直接對應 依需求用 IEnumerable<object?> 或非泛型 IList
reified T 不需要 C# 沒有型別擦除,泛型在 runtime 可用

C# 和 Kotlin 在型變的語法上幾乎一模一樣,都用 outin

最大的差別在 reified,C# 的泛型是具體化的(reified generics),runtime 保留型別資訊,不需要特殊語法,Kotlin(和 Java) 的泛型有型別擦除,需要靠 inline + reified 來繞過限制

型別擦除(type erasure)的意思是,泛型的型別引數只活在編譯期,編譯完的 bytecode 裡 List<String>List<Int> 都只剩下 List,runtime 拿不到 StringInt 這個資訊。完整的來龍去脈後面講 reified 的時候會再說

型變(Variance)

out:協變(Covariant)

out 表示型別參數只能被「產出」,不能被「消費」

// Kotlin 的 List 定義
public interface List<out E> : Collection<E> {
    operator fun get(index: Int): E   // 產出 E ← 合法
    // fun add(element: E)            // 消費 E ← 不存在,因為 List 是唯讀的
}

List<out E>List<String> 可以賦值給 List<Any>

val strings: List<String> = listOf("a", "b", "c")
val anys: List<Any> = strings  // 合法,因為 List 是 out E

為什麼可以?因為 List 只會「給你」元素(get),不會「收你的」元素(add)。你從 List<String> 拿出來的東西一定是 String,而 StringAny 的子型別,所以放進 List<Any> 的變數裡完全安全

in:逆變(Contravariant)

in 表示型別參數只能被「消費」,不能被「產出」

// Kotlin 的 Comparable 定義
public interface Comparable<in T> {
    operator fun compareTo(other: T): Int  // 消費 T ← 合法
    // fun getItem(): T                    // 產出 T ← 不允許
}

逆變的方向反過來,靠 Comparator<in String> 這種使用處型變,Comparator<Any> 就能用在需要 Comparator<String> 的地方

fun acceptComparator(c: Comparator<in String>) =
    listOf("c", "a", "b").sortedWith(c)

val anyComparator: Comparator<Any> = Comparator { a, b ->
    a.toString().compareTo(b.toString())
}

acceptComparator(anyComparator)  // 合法

為什麼反過來?因為 Comparator<Any> 能比較「任何東西」,那比較 String 當然也沒問題。能力越強的(接受 Any),可以用在要求越窄的地方(只要 String)

要注意 kotlin.Comparator 其實是 java.util.Comparator 的 typealias,本身是不變的(invariant),直接寫 val c: Comparator<String> = anyComparator 會編譯失敗。上面的例子能成立,是因為函式參數宣告成 Comparator<in String>,這是使用處型變(use-site projection),並不是 Comparator 自己有宣告處逆變

在 Collection API 中的體現

回顧之前寫過的函式

// myFilter 的 predicate 消費 T
inline fun <T> Iterable<T>.myFilter(predicate: (T) -> Boolean): List<T>
// (T) -> Boolean 其實是 Function1<in T, out Boolean>

Function1<in T, out Boolean>:T 是 in(被 Lambda 消費),Boolean 是 out(被 Lambda 產出)。day 16 用的 Comparator<in T> 也是逆變的,讓 Comparator<Any> 能排序 List<String>

宣告處 vs 使用處型變

// 宣告處型變(declaration-site):定義 class 時宣告
interface List<out E>        // 所有使用 List 的地方都是協變的
interface Comparable<in T>   // 所有使用 Comparable 的地方都是逆變的

// 使用處型變(use-site):使用時才宣告
fun copy(from: Array<out Any>, to: Array<Any>) { ... }
// 只有這個函式的 from 參數是協變的

Java 只有使用處型變(wildcards:? extends T? super T)。Kotlin 兩種都支援,但建議盡量用宣告處型變,因為宣告一次就夠了

星號投影(Star Projection)

* 等同 Java 的 ?(unbounded wildcard)

val list: List<*> = listOf(1, "hello", 3.0)
// List<*> 等於 List<out Any?>
// 可以讀(拿出來的型別是 Any?),不能寫
val first: Any? = list[0]

什麼時候用 *?當你不在乎具體型別,只想操作容器本身的時候

fun printSize(list: List<*>) {
    println("Size: ${list.size}")  // 不需要知道元素型別
}

TDD 實作 myFilterIsInstance

filterIsInstance 是 stdlib 裡面少數用到 reified 的函式。它從混合型別的集合裡過濾出特定型別的元素

為什麼需要 reified?

先看沒有 reified 的問題

// 這段無法編譯
fun <T> Iterable<*>.filterByType(): List<T> {
    val result = ArrayList<T>()
    for (element in this) {
        if (element is T) {  // 編譯錯誤:Cannot check for instance of erased type: T
            result.add(element)
        }
    }
    return result
}

Java/Kotlin 的泛型有型別擦除(type erasure)。T 在 runtime 不存在,compiler 不知道 T 到底是什麼型別,所以 element is T 無法編譯

reified 搭配 inline 可以解決這個問題。因為 inline 會把函式體展開到呼叫處,compiler 可以把具體的型別(例如 String)直接替換掉 T

型別擦除與 Java 泛型的向後相容需求有關

Java 在 1.5(2004)導入泛型時,已經有將近十年的 codebase,VectorArrayListHashMap 滿天飛。Java 1 寫的 .class 檔案要在 Java 1.5 的 JVM 上跑,Java 1.5 寫的泛型程式碼也要能跟舊 codebase 互通。如果 JVM 改成保留泛型型別資訊,ArrayList<String> 跟舊 ArrayList 會變成不同的 class,binary compatibility 就毀了

所以 Java 設計者選擇 erasure:編譯期做型別檢查,bytecode 把 ArrayList<String>ArrayList<Integer> 都擦成 ArrayList。runtime 看到的還是同一個 raw type,跟舊程式碼可以共存。代價就是 element is T 寫不出來。Kotlin 跑在 JVM 上,只能繼承這個包袱,靠 reified 在編譯期繞過

Red:先寫測試

@Test
fun `filter strings from mixed list`() {
    val mixed: List<Any> = listOf(1, "hello", 2.0, "world", 3)
    val result = mixed.myFilterIsInstance<String>()
    assertEquals(listOf("hello", "world"), result)
}

@Test
fun `filter ints from mixed list`() {
    val mixed: List<Any> = listOf(1, "hello", 2, "world", 3)
    val result = mixed.myFilterIsInstance<Int>()
    assertEquals(listOf(1, 2, 3), result)
}

@Test
fun `filter with no matches returns empty`() {
    val mixed: List<Any> = listOf(1, 2, 3)
    val result = mixed.myFilterIsInstance<String>()
    assertEquals(emptyList<String>(), result)
}

@Test
fun `filter drops null elements`() {
    val mixed: List<Any?> = listOf("a", null, "b", null)
    val result = mixed.myFilterIsInstance<String>()
    assertEquals(listOf("a", "b"), result)
}

@Test
fun `filter keeps nulls when type argument is nullable`() {
    val mixed: List<Any?> = listOf("a", null, "b", null)
    val result = mixed.myFilterIsInstance<String?>()
    assertEquals(listOf("a", null, "b", null), result)
}

最後兩個測試是一組對照。myFilterIsInstance<String> 會把 null 全部濾掉,因為 null is Stringfalse;換成 nullable 的型別引數 myFilterIsInstance<String?>null is String?truenull 就留下來了。同一份實作、同一份輸入,只差在型別引數帶不帶 ?,結果就不一樣,因為 reified 在呼叫處連 nullability 一起帶進去了

Green:最小實作

inline fun <reified R> Iterable<*>.myFilterIsInstance(): List<R> {
    val result = ArrayList<R>()
    for (element in this) {
        if (element is R) {
            result.add(element)
        }
    }
    return result
}

reified 讓 compiler 在 inline 呼叫處取得具體的 R,因此 element is RR::class 等受支援的型別操作可以編譯。它不會改變 JVM 的泛型型別擦除

這個寫法有三項限制

  1. 函式必須是 inline
  2. 型別參數必須標 reified
  3. 呼叫處的型別引數必須是編譯期已知的具體型別(像 <String>),不能是另一個還沒 reified 的型別參數

reified 也無法檢查被擦除的巢狀型別引數。例如可以判斷 value is List<*>,卻不能靠 is 確認它是 List<String>;後者在 runtime 只看得到 List

Refactor:往 stdlib 的寫法靠近

這個 Green 版已涵蓋 stdlib 的核心迴圈。stdlib 另外委派給 filterIsInstanceTo,並加入 @NoInfer 標記,後面的原始碼比較會再說明

與 C# 的比較

C# 不需要 reified,因為 C# 的泛型在 runtime 本來就是 reified 的

// C# 版
public static List<T> FilterByType<T>(this IEnumerable<object> source) {
    return source.OfType<T>().ToList();
}

typeof(T)is T 直接可用,不需要任何特殊處理

差別在 CLR 的設計。.NET 2.0(2005)導入泛型時,CLR 從頭設計,把泛型的型別資訊保留在 IL metadata 裡。對 value type(像 int),JIT 會為每個具體型別產生各自特化的原生碼,順便避開 boxing,對 reference type(像 stringobject),各種具體化的版本共用同一份原生碼(canonical shared instantiation),但 runtime 上 List<string>List<object> 仍是各自獨立的 Type,型別資訊都還在

代價是 .NET 1.x 的非泛型 collection 跟泛型 collection 是兩套並存(ArrayList vs List<T>),沒有像 Java 那樣的平滑升級

兩邊採用不同的相容策略,Kotlin 跑在 JVM 上,也受到 Java 型別擦除的限制,再以 reified 支援部分需要具體型別的操作

與 stdlib 原始碼比較

原始碼位置:kotlin.collections_Collections.kt

// stdlib 的 filterIsInstance
public inline fun <reified R> Iterable<*>.filterIsInstance(): List<@kotlin.internal.NoInfer R> {
    return filterIsInstanceTo(ArrayList<R>())
}

stdlib 多了一個 @NoInfer 標記和 filterIsInstanceTo 的委派模式(跟之前的 xxxTo 系列一樣,day 03 首見、day 10 詳講過)。核心邏輯跟我們的版本一樣

小結

outin 控制泛型的子型別關係方向

out 讓「子→父」的賦值合法(List),in 讓「父→子」的賦值合法(Comparator)。* 在你不在乎型別的時候用

reified 搭配 inline 可以繞過型別擦除,讓 is T 這種 runtime 型別檢查變成可能,C# 開發者轉 Kotlin 要特別注意型別擦除這個差異,C# 不需要 reified 是因為它根本沒有這個問題

下一篇講 Contract,看 Kotlin 怎麼讓 compiler 更聰明地理解 Lambda 的行為

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 33:自訂運算子與中綴函式 — 讓 Collection 操作更優雅
下一篇
Kotlin Lambda 從零開始 Day 35:Contract — 讓編譯器更聰明
系列文
Kotlin Lambda 從零開始35
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言